You:
Fork the existing page "railroad baron!!".Fetch its current HTML from https://tonkworks.com/page/railroad%20baron!!/raw/ and return an enhanced version as ONE complete, self-contained HTML document. Keep what already works.
What I want changed:
- make it so that trains stop running if track is removed/damaged; make it so you can buy differnt trains (with different speeds/power. Make it so you can connect special resources eg. (rubber farms to tire factories to make money). Make it so bridges cost more.
I built an enhanced Railroad Baron with track damage stopping trains, multiple purchasable train types with distinct stats, resource-chain gameplay (rubberβtires, ironβsteel, oilβfuel), and expensive water bridges.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Railroad Baron!!</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { --gold:#d4a017; --panel:rgba(15,15,30,0.92); }
* { box-sizing: border-box; }
html,body { margin:0; padding:0; background:#0b0b18; color:#eee;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; overflow:hidden; height:100%; }
#hud { position:fixed; top:0; left:0; right:0; padding:8px; background:var(--panel);
z-index:10; display:flex; gap:8px; align-items:center; flex-wrap:wrap;
border-bottom:2px solid #333; }
#hud .stat { padding:6px 12px; background:#1e1e38; border-radius:4px; font-weight:600; }
#hud .stat.goal { background:#3a2a0e; color:var(--gold); }
#hud button { padding:6px 12px; background:#3a3a5a; color:white; border:none;
border-radius:4px; cursor:pointer; font-size:14px; font-family:inherit; }
#hud button:hover { background:#4a4a7a; }
#hud button.active { background:var(--gold); color:#000; font-weight:700; }
#wrap { position:relative; margin-top:56px; display:flex; justify-content:center; }
#canvas { display:block; background:#223; image-rendering: pixelated; touch-action:none;
box-shadow:0 6px 30px rgba(0,0,0,0.6); }
#msg { position:fixed; bottom:8px; left:8px; right:8px; text-align:center; padding:8px;
background:rgba(0,0,0,0.75); border-radius:6px; font-size:14px; pointer-events:none;
border:1px solid #333; }
#shop { position:fixed; top:60px; right:8px; background:var(--panel); padding:10px;
border-radius:8px; display:none; width:260px; z-index:15; border:1px solid #444; }
#shop.open { display:block; }
#shop h3 { margin:0 0 8px 0; color:var(--gold); }
.train-opt { padding:8px; margin:6px 0; background:#242444; border-radius:5px;
cursor:pointer; border:2px solid transparent; transition:0.15s; }
.train-opt:hover { background:#333366; }
.train-opt.selected { border-color:var(--gold); background:#3a3a5a; }
.train-opt.disabled { opacity:0.4; cursor:not-allowed; }
.train-opt .name { font-weight:700; }
.train-opt .stats { font-size:12px; color:#aaa; margin-top:2px; }
#help { position:fixed; top:60px; left:8px; right:8px; bottom:40px; background:var(--panel);
padding:20px; border-radius:8px; z-index:30; display:none; overflow:auto;
border:1px solid #444; }
#help.open { display:block; }
#help h2 { color:var(--gold); margin-top:0; }
#help h3 { color:#7bed9f; }
#help .close { float:right; cursor:pointer; font-size:24px; color:#aaa; }
#tooltip { position:fixed; background:rgba(0,0,0,0.92); color:#fff; padding:6px 10px;
border-radius:5px; font-size:12px; pointer-events:none; display:none; z-index:20;
border:1px solid var(--gold); white-space:nowrap; }
.kb { background:#333; padding:1px 6px; border-radius:3px; font-family:monospace; border:1px solid #555; }
#victory { position:fixed; inset:0; display:none; align-items:center; justify-content:center;
background:rgba(0,0,0,0.85); z-index:40; flex-direction:column; }
#victory.on { display:flex; }
#victory h1 { color:var(--gold); font-size:64px; text-shadow:0 0 30px var(--gold); margin:0; }
#victory p { font-size:20px; }
#victory button { padding:10px 24px; font-size:16px; background:var(--gold); color:#000;
border:none; border-radius:6px; cursor:pointer; font-weight:700; }
</style>
</head>
<body>
<div id="hud">
<div class="stat">π° $<span id="money">500</span></div>
<div class="stat">π <span id="trainCount">0</span></div>
<div class="stat">π¦ <span id="delivered">0</span></div>
<div class="stat goal">π $<span id="goal">5000</span></div>
<button id="btnBuild" class="active">π¨ Build Track <span class="kb">1</span></button>
<button id="btnBuy">π Buy Train <span class="kb">2</span></button>
<button id="btnDemo">π₯ Demolish <span class="kb">3</span></button>
<button id="btnHelp">β Help</button>
<button id="btnNew">π New Map</button>
</div>
<div id="shop"></div>
<div id="wrap"><canvas id="canvas"></canvas></div>
<div id="msg">Click two cities or resource buildings to lay track between them.</div>
<div id="tooltip"></div>
<div id="help">
<span class="close" id="helpClose">β</span>
<h2>π Railroad Baron!! β Guide</h2>
<p><b>Goal:</b> Reach <b>$5,000</b> to become a Rail Baron.</p>
<h3>π¨ Building Track</h3>
<p>Click a city/building, then another. The cheapest route is auto-planned.
Grass $10 Β· Forest $15 Β· Mountain $40 Β· <b>Water Bridge $150</b> (avoid unless necessary).</p>
<h3>π Buying Trains</h3>
<p>Pick a train from the shop, then click two <i>connected</i> nodes.
Faster/higher-capacity trains earn more per trip but cost more upfront.</p>
<ul>
<li><b>Steam Engine</b> β cheap, slow, small</li>
<li><b>Diesel</b> β balanced middle-tier</li>
<li><b>Electric</b> β fast, premium</li>
<li><b>Freight Hauler</b> β huge capacity, slow</li>
</ul>
<h3>π Resource Chains (big bonus!)</h3>
<p>Connect a producer to its matching consumer for major payouts:</p>
<ul>
<li>π΄ Rubber Farm β βοΈ Tire Factory</li>
<li>βοΈ Iron Mine β π Steel Mill</li>
<li>π’οΈ Oil Well β βοΈ Refinery</li>
<li>Factories/Refineries can then feed ποΈ Cities for more profit</li>
</ul>
<h3>π₯ Demolition & Damage</h3>
<p>Removing track refunds a fraction of its cost.
<b>Any train whose route uses that tile will attempt to reroute; if none exists, it stops running until you rebuild!</b></p>
<h3>Hotkeys</h3>
<p><span class="kb">1</span> Build Β· <span class="kb">2</span> Buy Β· <span class="kb">3</span> Demolish Β· <span class="kb">Esc</span> Cancel selection</p>
</div>
<div id="victory">
<h1>π RAIL BARON!</h1>
<p>You built a $5,000 empire.</p>
<button id="playOn">Keep Playing</button>
</div>
<script>
// ============ CONFIG ============
const GRID_W = 22, GRID_H = 14;
let TILE = 42;
const T = { GRASS:0, FOREST:1, MOUNTAIN:2, WATER:3 };
const TERRAIN_COST = [10, 15, 40, 150]; // last is bridge cost (expensive!)
const TERRAIN_COLOR = ['#3d6b3a', '#254a1f', '#7d6a4a', '#2a5a8a'];
const TERRAIN_NAME = ['Grass','Forest','Mountain','Water (Bridge)'];
const NODES = {
city: { name:'City', color:'#f0f0f0', text:'#222', icon:'π', accepts:['*'] },
rubberFarm: { name:'Rubber Farm', color:'#5a8a3a', text:'#fff', icon:'π΄', produces:'rubber' },
tireFactory: { name:'Tire Factory', color:'#222', text:'#fff', icon:'β', accepts:['rubber'], produces:'tires' },
ironMine: { name:'Iron Mine', color:'#8a6a4a', text:'#fff', icon:'β', produces:'iron' },
steelMill: { name:'Steel Mill', color:'#8f8f9a', text:'#000', icon:'π', accepts:['iron'], produces:'steel' },
oilWell: { name:'Oil Well', color:'#1a1a1a', text:'#fff', icon:'π’', produces:'oil' },
refinery: { name:'Refinery', color:'#8a3a8a', text:'#fff', icon:'β', accepts:['oil'], produces:'fuel' },
};
const TRAIN_TYPES = {
steam: { name:'Steam Engine', cost:200, speed:0.030, capacity:1, color:'#d4a017', icon:'π' },
diesel: { name:'Diesel', cost:500, speed:0.055, capacity:2, color:'#4a90e2', icon:'π' },
electric: { name:'Electric', cost:1000, speed:0.085, capacity:3, color:'#7bed9f', icon:'π
' },
freight: { name:'Freight Hauler', cost:800, speed:0.035, capacity:5, color:'#e67e22', icon:'π' },
};
const GOAL = 5000;
// ============ STATE ============
let money, delivered, tickCount, mode, selectedNodeA, selectedTrainType,
grid, nodes, tracks, trains, floaters, mouseTile, mouseXY, baronReached;
function K(x,y){ return x+','+y; }
function newMap(){
money = 500;
delivered = 0;
tickCount = 0;
mode = 'build';
selectedNodeA = null;
selectedTrainType = 'steam';
grid = [];
nodes = [];
tracks = new Map();
trains = [];
floaters = [];
mouseTile = null;
baronReached = false;
document.getElementById('victory').classList.remove('on');
// Base terrain
for (let y=0; y<GRID_H; y++){
grid[y] = [];
for (let x=0; x<GRID_W; x++){
const r = Math.random();
let t;
if (r < 0.62) t = T.GRASS;
else if (r < 0.82) t = T.FOREST;
else if (r < 0.94) t = T.MOUNTAIN;
else t = T.WATER;
grid[y][x] = t;
}
}
// Add a winding river
let rx = 3 + Math.floor(Math.random()*(GRID_W-6));
for (let y=0; y<GRID_H; y++){
grid[y][rx] = T.WATER;
if (Math.random() < 0.35 && rx+1 < GRID_W) grid[y][rx+1] = T.WATER;
if (Math.random() < 0.5) rx += Math.random()<0.5?-1:1;
rx = Math.max(2, Math.min(GRID_W-3, rx));
}
// Place nodes with spacing
const nodePlan = [
'city','city','city','city','city',
'rubberFarm','tireFactory',
'ironMine','steelMill',
'oilWell','refinery'
];
for (const type of nodePlan){
for (let attempt=0; attempt<200; attempt++){
const nx = 1 + Math.floor(Math.random()*(GRID_W-2));
const ny = 1 + Math.floor(Math.random()*(GRID_H-2));
if (grid[ny][nx] === T.WATER) continue;
if (grid[ny][nx] === T.MOUNTAIN) continue;
const tooClose = nodes.some(n => Math.abs(n.x-nx)<=2 && Math.abs(n.y-ny)<=2);
if (tooClose) continue;
// Clear the immediate tile to grass for placement
grid[ny][nx] = T.GRASS;
nodes.push({ x:nx, y:ny, type, id:nodes.length });
break;
}
}
updateHUD();
fitCanvas();
showMessage('New map! Build track between nodes to move goods.');
}
// ============ PATHFINDING ============
function nodeAt(x,y){ return nodes.find(n => n.x===x && n.y===y) || null; }
function findTrackPath(start, end){
// A* honoring terrain cost; existing tracks are cheap; endpoint nodes allowed
const sKey = K(start.x,start.y), eKey = K(end.x,end.y);
const gScore = new Map(); gScore.set(sKey, 0);
const cameFrom = new Map();
const open = [[0, sKey]];
while (open.length){
open.sort((a,b)=>a[0]-b[0]);
const [_, cur] = open.shift();
if (cur === eKey){
const path = []; let c = cur;
while (c){ const [x,y]=c.split(',').map(Number); path.unshift({x,y}); c = cameFrom.get(c); }
return path;
}
const [cx,cy] = cur.split(',').map(Number);
const g = gScore.get(cur);
for (const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
const nx=cx+dx, ny=cy+dy;
if (nx<0||nx>=GRID_W||ny<0||ny>=GRID_H) continue;
const nkey = K(nx,ny);
const nAt = nodeAt(nx,ny);
// Can't pass through other nodes except the endpoint
if (nAt && nkey !== eKey) continue;
let cost;
if (nAt) cost = 1;
else if (tracks.has(nkey)) cost = 1;
else cost = TERRAIN_COST[grid[ny][nx]];
const ng = g + cost;
if (ng < (gScore.get(nkey) ?? Infinity)){
gScore.set(nkey, ng);
cameFrom.set(nkey, cur);
const h = Math.abs(nx-end.x)+Math.abs(ny-end.y);
open.push([ng+h, nkey]);
}
}
}
return null;
}
function findTrainPath(start, end){
// BFS across track-tiles + node tiles
const sKey = K(start.x,start.y), eKey = K(end.x,end.y);
const cameFrom = new Map(); cameFrom.set(sKey, null);
const q = [sKey];
while (q.length){
const cur = q.shift();
if (cur === eKey){
const path = []; let c = cur;
while (c !== null){
const [x,y] = c.split(',').map(Number); path.unshift({x,y});
c = cameFrom.get(c);
}
return path;
}
const [cx,cy] = cur.split(',').map(Number);
for (const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
const nx=cx+dx, ny=cy+dy;
if (nx<0||nx>=GRID_W||ny<0||ny>=GRID_H) continue;
const nkey = K(nx,ny);
if (cameFrom.has(nkey)) continue;
const nAt = nodeAt(nx,ny);
const passable = tracks.has(nkey) || (nAt && (nkey===eKey || nkey===sKey));
if (!passable) continue;
cameFrom.set(nkey, cur);
q.push(nkey);
}
}
return null;
}
// ============ ACTIONS ============
function pathCost(path){
let c = 0;
for (const p of path){
const k = K(p.x,p.y);
if (nodeAt(p.x,p.y)) continue;
if (tracks.has(k)) continue;
c += TERRAIN_COST[grid[p.y][p.x]];
}
return c;
}
function buildTrackBetween(a, b){
if (a.id === b.id){ showMessage('Choose a different endpoint.'); return; }
const path = findTrackPath(a,b);
if (!path){ showMessage('No possible route.'); return; }
const cost = pathCost(path);
if (cost > money){ showMessage(`Can't afford β need $${cost}, have $${money}.`); return; }
for (const p of path){
const k = K(p.x,p.y);
if (nodeAt(p.x,p.y)) continue;
if (!tracks.has(k)){
tracks.set(k, { isBridge: grid[p.y][p.x] === T.WATER });
}
}
money -= cost;
showMessage(`Built ${path.length-2} tiles for $${cost}. ${cost>=150?'β Bridges are expensive!':''}`);
updateHUD();
}
function computeTripPay(train){
const a = nodes[train.endpointA], b = nodes[train.endpointB];
const dist = train.route.length;
const cap = TRAIN_TYPES[train.type].capacity;
let pay = 6 + dist * 1.6;
const aI = NODES[a.type], bI = NODES[b.type];
const matchAB = aI.produces && bI.accepts && (bI.accepts.includes('*')||bI.accepts.includes(aI.produces));
const matchBA = bI.produces && aI.accepts && (aI.accepts.includes('*')||aI.accepts.includes(bI.produces));
if (matchAB) pay += 12 + dist * 2.5;
if (matchBA) pay += 12 + dist * 2.5;
// Extra bonus for perfect resource chain
if (matchAB && matchBA) pay *= 1.3;
return Math.max(4, Math.floor(pay * (0.6 + 0.4*cap)));
}
function buyTrainBetween(a, b){
if (a.id === b.id){ showMessage('Choose two different nodes.'); return; }
const spec = TRAIN_TYPES[selectedTrainType];
if (money < spec.cost){ showMessage(`Need $${spec.cost} for ${spec.name}.`); return; }
const path = findTrainPath(a,b);
if (!path){ showMessage('Those nodes are not connected by track.'); return; }
money -= spec.cost;
const tr = {
type: selectedTrainType,
endpointA: a.id,
endpointB: b.id,
route: path,
seg: 0, // index into route (from - the segment starts here)
prog: 0, // 0..1 progress toward route[seg+1]
dir: 1,
stopped: false,
id: trains.length,
};
tr.pay = computeTripPay(tr);
trains.push(tr);
showMessage(`Bought ${spec.name}! Payout $${tr.pay}/trip.`);
updateHUD();
}
function demolishAt(x,y){
const k = K(x,y);
if (!tracks.has(k)){ showMessage('No track there.'); return; }
const tile = tracks.get(k);
const original = tile.isBridge ? 150 : TERRAIN_COST[grid[y][x]];
const refund = Math.max(1, Math.floor(original * 0.25));
tracks.delete(k);
money += refund;
// Break/reroute any train that uses this tile
let broken = 0, rerouted = 0;
for (const tr of trains){
if (tr.route.some(p => p.x===x && p.y===y)){
const a = nodes[tr.endpointA], b = nodes[tr.endpointB];
const np = findTrainPath(a,b);
if (np){
tr.route = np;
tr.seg = Math.min(tr.seg, np.length-2);
tr.prog = 0;
tr.stopped = false;
tr.pay = computeTripPay(tr);
rerouted++;
} else {
tr.stopped = true;
broken++;
}
}
}
let msg = `Removed track, refund $${refund}.`;
if (broken) msg += ` π¨ ${broken} train${broken>1?'s':''} stranded!`;
if (rerouted) msg += ` ${rerouted} rerouted.`;
showMessage(msg);
updateHUD();
}
// ============ TRAIN UPDATE ============
function tickTrains(){
for (const tr of trains){
if (tr.stopped) continue;
// Safety: ensure current segment still valid (both endpoints have track/node)
if (tr.seg < 0 || tr.seg >= tr.route.length-1){
tr.seg = Math.max(0, Math.min(tr.route.length-2, tr.seg));
}
const from = tr.route[tr.seg];
const to = tr.route[tr.seg + 1];
// Validate both tiles exist as track or node β else stop
const fromOK = !!(nodeAt(from.x,from.y) || tracks.has(K(from.x,from.y)));
const toOK = !!(nodeAt(to.x,to.y) || tracks.has(K(to.x,to.y)));
if (!fromOK || !toOK){
// Attempt reroute
const a = nodes[tr.endpointA], b = nodes[tr.endpointB];
const np = findTrainPath(a,b);
if (np){ tr.route = np; tr.seg = 0; tr.prog = 0; tr.pay = computeTripPay(tr); }
else { tr.stopped = true; }
continue;
}
tr.prog += TRAIN_TYPES[tr.type].speed;
while (tr.prog >= 1){
tr.prog -= 1;
tr.seg += tr.dir;
if (tr.seg >= tr.route.length - 1){
tr.seg = tr.route.length - 1;
tr.dir = -1;
arriveAt(tr, tr.endpointB);
} else if (tr.seg <= 0){
tr.seg = 0;
tr.dir = 1;
arriveAt(tr, tr.endpointA);
}
}
}
// Update floaters
for (let i = floaters.length-1; i>=0; i--){
floaters[i].t += 1;
floaters[i].y -= 0.6;
if (floaters[i].t > 60) floaters.splice(i,1);
}
}
function arriveAt(tr, endpointId){
money += tr.pay;
delivered++;
const n = nodes[endpointId];
floaters.push({ x: n.x*TILE + TILE/2, y: n.y*TILE, txt:`+$${tr.pay}`, t:0, color:TRAIN_TYPES[tr.type].color });
if (money >= GOAL && !baronReached){
baronReached = true;
document.getElementById('victory').classList.add('on');
}
updateHUD();
}
// ============ RENDERING ============
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function fitCanvas(){
const maxW = window.innerWidth - 20;
const maxH = window.innerHeight - 120;
TILE = Math.floor(Math.min(maxW/GRID_W, maxH/GRID_H));
TILE = Math.max(24, Math.min(48, TILE));
canvas.width = GRID_W * TILE;
canvas.height = GRID_H * TILE;
}
function drawTerrain(){
for (let y=0; y<GRID_H; y++){
for (let x=0; x<GRID_W; x++){
const t = grid[y][x];
ctx.fillStyle = TERRAIN_COLOR[t];
ctx.fillRect(x*TILE, y*TILE, TILE, TILE);
if (t === T.FOREST){
ctx.fillStyle = '#1a3a15';
for (let i=0;i<3;i++){
const dx = ((x*13+i*7)%TILE);
const dy = ((y*11+i*5)%TILE);
ctx.beginPath();
ctx.arc(x*TILE+dx, y*TILE+dy, TILE*0.12, 0, Math.PI*2);
ctx.fill();
}
} else if (t === T.MOUNTAIN){
ctx.fillStyle = '#544230';
ctx.beginPath();
ctx.moveTo(x*TILE+TILE*0.15, y*TILE+TILE*0.85);
ctx.lineTo(x*TILE+TILE*0.5, y*TILE+TILE*0.20);
ctx.lineTo(x*TILE+TILE*0.85, y*TILE+TILE*0.85);
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#f0f0f0';
ctx.beginPath();
ctx.moveTo(x*TILE+TILE*0.42, y*TILE+TILE*0.34);
ctx.lineTo(x*TILE+TILE*0.5, y*TILE+TILE*0.20);
ctx.lineTo(x*TILE+TILE*0.58, y*TILE+TILE*0.34);
ctx.closePath(); ctx.fill();
} else if (t === T.WATER){
// subtle waves
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
const off = (tickCount*0.5 + (x+y)*10) % TILE;
ctx.beginPath();
ctx.moveTo(x*TILE, y*TILE+TILE*0.4+Math.sin(off*0.2)*2);
ctx.lineTo(x*TILE+TILE, y*TILE+TILE*0.4+Math.cos(off*0.2)*2);
ctx.stroke();
}
}
}
// grid overlay
ctx.strokeStyle = 'rgba(0,0,0,0.18)';
ctx.lineWidth = 1;
for (let x=0;x<=GRID_W;x++){
ctx.beginPath(); ctx.moveTo(x*TILE,0); ctx.lineTo(x*TILE,GRID_H*TILE); ctx.stroke();
}
for (let y=0;y<=GRID_H;y++){
ctx.beginPath(); ctx.moveTo(0,y*TILE); ctx.lineTo(GRID_W*TILE,y*TILE); ctx.stroke();
}
}
function hasTrackAt(x,y){
return tracks.has(K(x,y)) || !!nodeAt(x,y);
}
function drawTracks(){
// For each track tile, draw connections to neighbors
for (const [key, tile] of tracks){
const [x,y] = key.split(',').map(Number);
const cx = x*TILE + TILE/2, cy = y*TILE + TILE/2;
// Bridge base
if (tile.isBridge){
ctx.fillStyle = '#6a4a2a';
ctx.fillRect(x*TILE+2, y*TILE+TILE*0.3, TILE-4, TILE*0.4);
ctx.strokeStyle = '#3a2a1a';
ctx.lineWidth = 1;
ctx.strokeRect(x*TILE+2, y*TILE+TILE*0.3, TILE-4, TILE*0.4);
}
drawTrackTile(x,y,cx,cy);
}
// Also draw rails going into nodes (short stubs)
for (const n of nodes){
const cx = n.x*TILE+TILE/2, cy = n.y*TILE+TILE/2;
drawTrackTile(n.x, n.y, cx, cy, true);
}
}
function drawTrackTile(x,y,cx,cy,isNode=false){
const neighbors = [
{dx:1,dy:0}, {dx:-1,dy:0}, {dx:0,dy:1}, {dx:0,dy:-1}
];
ctx.strokeStyle = '#2a2a2a';
ctx.lineWidth = Math.max(3, TILE*0.12);
for (const {dx,dy} of neighbors){
const nx=x+dx, ny=y+dy;
if (!hasTrackAt(nx,ny)) continue;
if (isNode && !tracks.has(K(nx,ny))) continue; // node stubs only toward real tracks
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + dx*TILE*0.5, cy + dy*TILE*0.5);
ctx.stroke();
}
// Rail ties on top
if (!isNode){
ctx.strokeStyle = '#c0c0c0';
ctx.lineWidth = Math.max(1, TILE*0.04);
for (const {dx,dy} of neighbors){
const nx=x+dx, ny=y+dy;
if (!hasTrackAt(nx,ny)) continue;
// parallel rail lines
for (const off of [-1,1]){
ctx.beginPath();
const perp = { x:-dy*off*TILE*0.12, y:dx*off*TILE*0.12 };
ctx.moveTo(cx+perp.x, cy+perp.y);
ctx.lineTo(cx+dx*TILE*0.5+perp.x, cy+dy*TILE*0.5+perp.y);
ctx.stroke();
}
}
}
}
function drawNodes(){
for (const n of nodes){
const info = NODES[n.type];
const x = n.x*TILE, y = n.y*TILE;
ctx.fillStyle = info.color;
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x+3, y+3, TILE-6, TILE-6, 4);
ctx.fill(); ctx.stroke();
ctx.fillStyle = info.text;
ctx.font = `${Math.floor(TILE*0.55)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(info.icon, x+TILE/2, y+TILE/2+1);
}
}
function drawTrains(){
for (const tr of trains){
const spec = TRAIN_TYPES[tr.type];
const from = tr.route[tr.seg];
const to = tr.route[tr.seg + (tr.dir>0?1:1)] || tr.route[tr.seg];
// interpolate
const nextIdx = Math.min(tr.route.length-1, tr.seg + 1);
const a = tr.route[tr.seg];
const b = tr.route[nextIdx];
let fx = a.x + (b.x-a.x)*tr.prog;
let fy = a.y + (b.y-a.y)*tr.prog;
const px = fx*TILE + TILE/2;
const py = fy*TILE + TILE/2;
const size = TILE*0.55;
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath();
ctx.ellipse(px, py+size*0.4, size*0.5, size*0.15, 0, 0, Math.PI*2);
ctx.fill();
// body
ctx.fillStyle = tr.stopped ? '#666' : spec.color;
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
const angle = Math.atan2(b.y-a.y, b.x-a.x);
ctx.save();
ctx.translate(px,py);
ctx.rotate(angle);
ctx.beginPath();
ctx.roundRect(-size*0.5, -size*0.3, size, size*0.6, 4);
ctx.fill(); ctx.stroke();
// cab window
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(size*0.1, -size*0.2, size*0.25, size*0.4);
// smoke stack for steam / freight
if (tr.type === 'steam' || tr.type==='freight'){
ctx.fillStyle = '#000';
ctx.fillRect(-size*0.35, -size*0.45, size*0.15, size*0.2);
}
ctx.restore();
// stopped indicator
if (tr.stopped){
ctx.fillStyle = '#ff4444';
ctx.font = `${Math.floor(TILE*0.3)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('β ', px, py - size*0.6);
}
}
}
function drawPreview(){
if (!mouseTile) return;
if (mode==='build' || mode==='buy'){
if (!selectedNodeA) return;
const target = nodeAt(mouseTile.x, mouseTile.y);
if (!target || target.id===selectedNodeA.id) return;
const path = mode==='build' ? findTrackPath(selectedNodeA, target) : findTrainPath(selectedNodeA, target);
if (!path){
ctx.fillStyle = 'rgba(255,80,80,0.3)';
ctx.fillRect(target.x*TILE, target.y*TILE, TILE, TILE);
return;
}
let cost = 0;
if (mode==='build') cost = pathCost(path);
ctx.strokeStyle = mode==='build' ? 'rgba(255,215,0,0.85)' : 'rgba(120,255,180,0.85)';
ctx.lineWidth = Math.max(3, TILE*0.15);
ctx.beginPath();
for (let i=0;i<path.length;i++){
const p = path[i];
if (i===0) ctx.moveTo(p.x*TILE+TILE/2, p.y*TILE+TILE/2);
else ctx.lineTo(p.x*TILE+TILE/2, p.y*TILE+TILE/2);
}
ctx.stroke();
if (mode==='build'){
ctx.fillStyle = '#000';
ctx.fillRect(mouseTile.x*TILE, mouseTile.y*TILE-18, 70, 16);
ctx.fillStyle = cost>money ? '#ff5555' : '#ffd700';
ctx.font = '12px sans-serif';
ctx.textAlign='left';
ctx.fillText(`$${cost}`, mouseTile.x*TILE+4, mouseTile.y*TILE-5);
}
} else if (mode==='demolish'){
const k = K(mouseTile.x, mouseTile.y);
if (tracks.has(k)){
ctx.fillStyle = 'rgba(255,80,80,0.4)';
ctx.fillRect(mouseTile.x*TILE, mouseTile.y*TILE, TILE, TILE);
}
}
// Hover highlight over source node
if (selectedNodeA){
ctx.strokeStyle = '#ffd700';
ctx.lineWidth = 3;
ctx.strokeRect(selectedNodeA.x*TILE+2, selectedNodeA.y*TILE+2, TILE-4, TILE-4);
}
}
function drawFloaters(){
ctx.textAlign = 'center';
ctx.font = `bold ${Math.floor(TILE*0.35)}px sans-serif`;
for (const f of floaters){
ctx.fillStyle = f.color;
ctx.globalAlpha = 1 - f.t/60;
ctx.fillText(f.txt, f.x, f.y);
ctx.globalAlpha = 1;
}
}
function draw(){
tickCount++;
ctx.fillStyle = '#000';
ctx.fillRect(0,0,canvas.width,canvas.height);
drawTerrain();
drawTracks();
drawNodes();
drawTrains();
drawPreview();
drawFloaters();
}
// ============ INPUT ============
function getTileFromEvent(e){
const rect = canvas.getBoundingClientRect();
const cx = (e.touches?e.touches[0].clientX:e.clientX) - rect.left;
const cy = (e.touches?e.touches[0].clientY:e.clientY) - rect.top;
const x = Math.floor(cx / TILE);
const y = Math.floor(cy / TILE);
if (x<0||x>=GRID_W||y<0||y>=GRID_H) return null;
return {x,y};
}
canvas.addEventListener('mousemove', (e)=>{
mouseTile = getTileFromEvent(e);
mouseXY = { x: e.clientX, y: e.clientY };
updateTooltip();
});
canvas.addEventListener('mouseleave', ()=>{
mouseTile = null;
hideTooltip();
});
function updateTooltip(){
if (!mouseTile){ hideTooltip(); return; }
const n = nodeAt(mouseTile.x, mouseTile.y);
const tt = document.getElementById('tooltip');
if (n){
const info = NODES[n.type];
let txt = `<b>${info.name}</b>`;
if (info.produces) txt += `<br>Produces: ${info.produces}`;
if (info.accepts) txt += `<br>Accepts: ${info.accepts.join(', ')}`;
tt.innerHTML = txt;
tt.style.display = 'block';
tt.style.left = (mouseXY.x + 12) + 'px';
tt.style.top = (mouseXY.y + 12) + 'px';
} else if (tracks.has(K(mouseTile.x,mouseTile.y))){
const isB = tracks.get(K(mouseTile.x,mouseTile.y)).isBridge;
tt.innerHTML = isB ? '<b>Bridge</b><br>Fragile β costly to lose!' : '<b>Track</b>';
tt.style.display = 'block';
tt.style.left = (mouseXY.x + 12) + 'px';
tt.style.top = (mouseXY.y + 12) + 'px';
} else {
const t = grid[mouseTile.y][mouseTile.x];
tt.innerHTML = `<b>${TERRAIN_NAME[t]}</b><br>Track cost: $${TERRAIN_COST[t]}`;
tt.style.display = 'block';
tt.style.left = (mouseXY.x + 12) + 'px';
tt.style.top = (mouseXY.y + 12) + 'px';
}
}
function hideTooltip(){ document.getElementById('tooltip').style.display='none'; }
canvas.addEventListener('click', (e)=>{
const tile = getTileFromEvent(e);
if (!tile) return;
if (mode === 'demolish'){
demolishAt(tile.x, tile.y);
return;
}
const n = nodeAt(tile.x, tile.y);
if (!n){
if (mode==='build' || mode==='buy'){
showMessage('Click on a city or building.');
}
return;
}
if (!selectedNodeA){
selectedNodeA = n;
showMessage(`Selected ${NODES[n.type].name}. Now click destination.`);
} else {
if (mode==='build') buildTrackBetween(selectedNodeA, n);
else if (mode==='buy') buyTrainBetween(selectedNodeA, n);
selectedNodeA = null;
}
});
// ============ UI ============
function setMode(m){
mode = m;
selectedNodeA = null;
document.getElementById('btnBuild').classList.toggle('active', m==='build');
document.getElementById('btnBuy').classList.toggle('active', m==='buy');
document.getElementById('btnDemo').classList.toggle('active', m==='demolish');
document.getElementById('shop').classList.toggle('open', m==='buy');
if (m==='build') showMessage('Build: click two nodes. Auto-plans cheapest route.');
if (m==='buy') showMessage('Buy: pick a train type, then click two connected nodes.');
if (m==='demolish') showMessage('Demolish: click any track tile. β Trains using it may stop!');
}
function renderShop(){
const shop = document.getElementById('shop');
let html = '<h3>π Train Shop</h3>';
for (const key of Object.keys(TRAIN_TYPES)){
const t = TRAIN_TYPES[key];
const canBuy = money >= t.cost;
const sel = key === selectedTrainType ? 'selected' : '';
const dis = canBuy ? '' : 'disabled';
html += `<div class="train-opt ${sel} ${dis}" data-key="${key}">
<div class="name">${t.icon} ${t.name} β $${t.cost}</div>
<div class="stats">Speed ${(t.speed*100).toFixed(1)} Β· Cap ${t.capacity}</div>
</div>`;
}
shop.innerHTML = html;
shop.querySelectorAll('.train-opt').forEach(el=>{
el.addEventListener('click', ()=>{
if (el.classList.contains('disabled')) return;
selectedTrainType = el.dataset.key;
renderShop();
});
});
}
function updateHUD(){
document.getElementById('money').textContent = money;
document.getElementById('trainCount').textContent = trains.length;
document.getElementById('delivered').textContent = delivered;
renderShop();
}
function showMessage(t){
document.getElementById('msg').textContent = t;
}
document.getElementById('btnBuild').addEventListener('click', ()=>setMode('build'));
document.getElementById('btnBuy').addEventListener('click', ()=>setMode('buy'));
document.getElementById('btnDemo').addEventListener('click', ()=>setMode('demolish'));
document.getElementById('btnNew').addEventListener('click', newMap);
document.getElementById('btnHelp').addEventListener('click', ()=>{
document.getElementById('help').classList.add('open');
});
document.getElementById('helpClose').addEventListener('click', ()=>{
document.getElementById('help').classList.remove('open');
});
document.getElementById('playOn').addEventListener('click', ()=>{
document.getElementById('victory').classList.remove('on');
});
document.addEventListener('keydown', (e)=>{
if (e.key==='1') setMode('build');
else if (e.key==='2') setMode('buy');
else if (e.key==='3') setMode('demolish');
else if (e.key==='Escape') { selectedNodeA = null; showMessage('Cancelled.'); }
});
window.addEventListener('resize', ()=>{
fitCanvas();
});
// ============ GAME LOOP ============
function loop(){
tickTrains();
draw();
requestAnimationFrame(loop);
}
// Polyfill roundRect on old canvases
if (!CanvasRenderingContext2D.prototype.roundRect){
CanvasRenderingContext2D.prototype.roundRect = function(x,y,w,h,r){
if (typeof r==='number') r = {tl:r,tr:r,br:r,bl:r};
else if (typeof r==='object' && r.length){ r = {tl:r[0],tr:r[1],br:r[2],bl:r[3]}; }
this.moveTo(x+r.tl, y);
this.lineTo(x+w-r.tr, y);
this.quadraticCurveTo(x+w,y,x+w,y+r.tr);
this.lineTo(x+w, y+h-r.br);
this.quadraticCurveTo(x+w,y+h,x+w-r.br,y+h);
this.lineTo(x+r.bl, y+h);
this.quadraticCurveTo(x,y+h,x,y+h-r.bl);
this.lineTo(x, y+r.tl);
this.quadraticCurveTo(x,y,x+r.tl,y);
return this;
};
}
newMap();
loop();
</script>
</body>
</html>